I was able to achieve this functionality by essentially deleting and recreating the ring around the token whenever the token moves. Sharing it here in case anyone wants to do something similar. You can achieve the same thing with Auras, but those don't support full transparent circles, and were a little more cumbersome than what I wanted to do. It allows you to show the range of a character's attack based on a 5e Character Sheet's ranged attack distance. /**
* RangeRing — Delete and recreate version
*/
(() => {
'use strict';
const CFG = {
defaultMode: 'long',
stroke: '#ff0000',
strokeWidth: 4,
segments: 48,
layer: 'objects',
sendToBack: true,
gmOnly: false,
rangeAttrRegexes: [
/^repeating_attack_.*_atkrange$/i,
/^repeating_attack_.*_range$/i,
/^repeating_npcaction_.*_range$/i,
/^repeating_npcaction_.*_rangereach$/i,
/^ranged_range$/i,
/^max_ranged_range$/i,
/^range_max$/i,
],
extractNumbers: (s) =>
(String(s).match(/\d+/g) || [])
.map(n => parseInt(n, 10))
.filter(Number.isFinite),
};
state.RangeRing = state.RangeRing || { rings: {} };
const getPageFeetPerUnit = (pageId) => {
const page = getObj('page', pageId);
const scale = page ? parseFloat(page.get('scale_number')) : 5;
return Number.isFinite(scale) && scale > 0 ? scale : 5;
};
const getPagePxPerUnit = () => 70;
const tokenHalfWidthUnits = (token) => {
const wUnits = (parseFloat(token.get('width')) || 70) / getPagePxPerUnit();
return wUnits / 2;
};
const parseRangeFeet = (raw, mode) => {
if (raw == null || raw === '') return 0;
const nums = CFG.extractNumbers(raw);
if (!nums.length) return 0;
if (nums.length === 1) return nums[0];
return mode === 'short' ? nums[0] : nums[nums.length - 1];
};
const findMaxRangedFeetFromCharacter = (charId, mode) => {
const attrs = findObjs({ _type: 'attribute', _characterid: charId }) || [];
let maxFeet = 0;
for (const a of attrs) {
const name = a.get('name') || '';
if (!CFG.rangeAttrRegexes.some(re => re.test(name))) continue;
const cur = a.get('current');
const feet = parseRangeFeet(cur, mode);
if (feet > maxFeet) maxFeet = feet;
}
return maxFeet;
};
const buildCirclePath = (radiusPx, segments) => {
const r = radiusPx;
const cx = r;
const cy = r;
const pts = [];
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
const x = cx + r * Math.cos(theta);
const y = cy + r * Math.sin(theta);
pts.push([x, y]);
}
const path = [];
path.push(['M', pts[0][0], pts[0][1]]);
for (let i = 1; i < pts.length; i++) {
path.push(['L', pts[i][0], pts[i][1]]);
}
return JSON.stringify(path);
};
const createRing = (token, radiusUnits) => {
const tokenId = token.id;
const pageId = token.get('pageid');
const pxPerUnit = getPagePxPerUnit();
const radiusPx = radiusUnits * pxPerUnit;
const sizePx = radiusPx * 2;
const ringLayer = CFG.gmOnly ? 'gmlayer' : CFG.layer;
const ringPath = buildCirclePath(radiusPx, CFG.segments);
// Delete old ring if it exists
const existing = state.RangeRing.rings[tokenId];
if (existing && existing.pathId) {
const oldPath = getObj('path', existing.pathId);
if (oldPath) oldPath.remove();
}
// Create new ring
const p = createObj('path', {
pageid: pageId,
layer: ringLayer,
left: token.get('left'),
top: token.get('top'),
width: sizePx,
height: sizePx,
path: ringPath,
stroke: CFG.stroke,
stroke_width: CFG.strokeWidth,
fill: 'transparent',
controlledby: '',
});
state.RangeRing.rings[tokenId] = { pathId: p.id, radiusUnits };
if (CFG.sendToBack) toBack(p);
};
const removeRing = (tokenId) => {
const rec = state.RangeRing.rings[tokenId];
if (!rec) return;
const p = getObj('path', rec.pathId);
if (p) p.remove();
delete state.RangeRing.rings[tokenId];
};
const parseManualRadius = (arg, pageFeetPerUnit) => {
if (!arg) return null;
const s = String(arg).trim().toLowerCase();
if (!s) return null;
if (s.endsWith('u')) {
const n = parseFloat(s.slice(0, -1));
return Number.isFinite(n) && n > 0 ? n : null;
}
if (s.endsWith('ft')) {
const n = parseFloat(s.slice(0, -2));
if (!Number.isFinite(n) || n <= 0) return null;
return n / pageFeetPerUnit;
}
const n = parseFloat(s);
if (!Number.isFinite(n) || n <= 0) return null;
return n / pageFeetPerUnit;
};
on('chat:message', (msg) => {
if (msg.type !== 'api') return;
if (!msg.content || !msg.content.toLowerCase().startsWith('!range')) return;
const parts = msg.content.split(/\s+/).slice(1);
const selected = msg.selected || [];
if (!selected.length) {
sendChat('RangeRing', `/w gm Select one or more tokens first.`);
return;
}
let mode = CFG.defaultMode;
let stroke = CFG.stroke;
let strokeWidth = CFG.strokeWidth;
let segments = CFG.segments;
let manualArg = null;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === '--short') mode = 'short';
else if (p === '--long') mode = 'long';
else if (p === '--stroke' && parts[i + 1]) { stroke = parts[++i]; }
else if (p === '--width' && parts[i + 1]) { strokeWidth = parseInt(parts[++i], 10) || strokeWidth; }
else if (p === '--segments' && parts[i + 1]) { segments = parseInt(parts[++i], 10) || segments; }
else if (!p.startsWith('--') && !manualArg) manualArg = p;
}
CFG.stroke = stroke;
CFG.strokeWidth = strokeWidth;
CFG.segments = Math.max(12, Math.min(180, segments));
selected.forEach(sel => {
const token = getObj('graphic', sel._id);
if (!token) return;
const tokenId = token.id;
if (state.RangeRing.rings[tokenId]) {
removeRing(tokenId);
return;
}
const pageFeetPerUnit = getPageFeetPerUnit(token.get('pageid'));
let radiusUnits = parseManualRadius(manualArg, pageFeetPerUnit);
const edgeOffset = tokenHalfWidthUnits(token);
if (radiusUnits != null) {
radiusUnits = radiusUnits + edgeOffset;
createRing(token, radiusUnits);
return;
}
const charId = token.get('represents');
if (!charId) {
sendChat('RangeRing', `/w gm Token "${token.get('name') || tokenId}" does not represent a character. Use !range 150ft or set represents.`);
return;
}
const maxFeet = findMaxRangedFeetFromCharacter(charId, mode);
if (!maxFeet || maxFeet <= 0) {
sendChat('RangeRing', `/w gm No ranged attack ranges found for "${token.get('name') || 'token'}". Either add a matching attribute pattern or use !range 150ft / !range 3u.`);
return;
}
radiusUnits = Math.ceil(maxFeet / pageFeetPerUnit) + edgeOffset;
createRing(token, radiusUnits);
});
});
// DELETE AND RECREATE on every move
on('change:graphic', (obj, prev) => {
const rec = state.RangeRing.rings[obj.id];
if (!rec) return;
const leftChanged = obj.get('left') !== prev.left;
const topChanged = obj.get('top') !== prev.top;
const pageChanged = prev.pageid !== undefined && obj.get('pageid') !== prev.pageid;
if (!leftChanged && !topChanged && !pageChanged) return;
// Just recreate the ring at the new position
createRing(obj, rec.radiusUnits);
});
on('destroy:graphic', (obj) => {
removeRing(obj.id);
});
})();